require 'active_support/concern'
require 'active_support/core_ext/array/extract_options'
Dir.glob(File.dirname(__FILE__) + '/serializers/*').each { |f| require f }

# Mixin that adds the ability for you to define a serializable representation of
# your object. `include` this module into your class and call
# {ClassMethods#serialize_fields} and {ClassMethods#serialize_with} to
# configure.

module SerialBox
  extend ActiveSupport::Concern

  # Methods added to the class that this module is mixed into.

  module ClassMethods

    # Call this method to define how your object is serialized. This method
    # yields an object on which you call methods to define your serialization.
    #
    # @yield [serializer] A block where you can define how your object is
    #   serialized.
    # @yieldparam [SerialBox::Serializer] serializer An object you can use to
    #   define your serialization strategy.

    def serialize_fields
      @_serialbox_serializer = Serializer.new(self)
      yield @_serialbox_serializer
    end

    # @overload serialize_with(serializer, ...)
    #   Call this method to specify which serialization formats to use.
    #
    #   @param [Symbol, Class] serializer A serialization format adapter or the
    #     name of such adapter under the `SerialBox::Serializers` namespace. See
    #     the classes under {SerialBox::Serializers} for a list of possible
    #     values.

    def serialize_with(*serializers)
      serializers.map! do |s|
        case s
          when Class
            s
          when Symbol, String
            SerialBox::Serializers.const_get(s.to_sym)
          else
            raise ArgumentError, "Unknown serializer #{s.inspect}"
        end
      end

      serializers.each { |serializer| include serializer }
    end

    # @private
    def _serialbox_serializer() @_serialbox_serializer end
  end


  # An object that is used to define how a class is serialized. The methods of
  # this object can be called within a block given to
  # {SerialBox::ClassMethods#serialize_fields}.

  class Serializer
    # @private
    attr_reader :serialization_operations, :deserialization_operations, :object

    # @private
    def initialize(object)
      @serialization_operations   = Array.new
      @deserialization_operations = Array.new
      @object                     = object
    end

    # @overload serialize(field, ..., options={})
    #
    #   Specifies that one or more fields should be serialized in the most
    #   obvious manner. This is suitable when serializing a simple getter/setter
    #   pair or an instance variable.
    #
    #   @param [Symbol] field The name of a method (or instance variable with
    #     "@" sigil) that will be serialized.
    #   @param [Hash] options Additional options controlling how the field is
    #     serialized.
    #   @option options [String] :into If given, specifies that the field should
    #     be serialized into a key with a different name. Can only be provided
    #     if a single field is given.

    def serialize(*fields)
      options = fields.extract_options!
      if options[:into] && fields.size > 1
        raise ArgumentError, "Can't specify :into option with multiple fields"
      end

      fields.each do |field|
        if field.to_s[0, 1] == '@'
          field      = field.to_s[1..-1]
          json_field = options[:into] || field
          serializer(json_field) { instance_variable_get :"@#{field}" }
          deserializer(json_field) { |value| instance_variable_set :"@#{field}", value }
        else
          json_field = options[:into] || field
          serializer json_field, field
          deserializer json_field, :"#{field}="
        end
      end
    end

    # Defines how a field is handled when an object is prepared for
    # serialization.
    #
    # @overload serializer(json_field, object_method)
    #   Specifies that the method `object_method` should be called and its
    #   result should be stored in a field named `json_field` as part of
    #   serialization.
    #   @param [String] json_field The field to store the resulting value in.
    #   @param [Symbol] object_method The method to call to retrieve a value.
    #
    # @overload serializer(json_field)
    #   Specifies that a block should be called in the context of the instance
    #   being serialized, and the result should be stored in a field named
    #   `json_field`.
    #   @param [String] json_field The field to store the resulting value in in
    #     the JSON representation.
    #   @yield A block that returns the value to serialize.
    #   @yieldreturn The value to serialize into `json_field`.

    def serializer(json_field, object_method=nil, &block)
      if block_given?
        @serialization_operations << BlockSerialization.new(json_field, block)
      elsif object_method
        @serialization_operations << BlockSerialization.new(json_field, lambda { send object_method })
      else
        raise ArgumentError, "Must provide the name of a method or a block that returns a value to serialize"
      end
    end

    # Defines how a field is handled when an object is deserialized from its
    # primitive representation.
    #
    # @overload deserializer(json_field, object_method)
    #   Specifies that the value in `json_field` should be passed to
    #   `object_method` during deserialization.
    #   @param [String] json_field The field to retrieve the value from.
    #   @param [Symbol] object_method The method to call to store the value
    #     into the object.
    #
    # @overload deserializer(json_field)
    #   Specifies that the value stored in `json_field` should be passed to the
    #   block provided, which will store that value into the object being
    #   deserialized.
    #   @param [String] json_field The field to retrieve the value from in the
    #     JSON representation.
    #   @yield [value] A block that stores the value into the object. This block
    #     is executed in the context of the object.
    #   @yieldparam value The value to store.

    def deserializer(json_field, object_method=nil, &block)
      if block_given?
        @deserialization_operations << BlockDeserialization.new(json_field, block, object)
      elsif object_method
        @deserialization_operations << BlockDeserialization.new(json_field, lambda { |value| send object_method, value }, object)
      else
        raise ArgumentError, "Must provide the name of a method or a block that sets a field from a deserialized value"
      end
    end

    # @private
    class BlockSerialization
      def initialize(field, block)
        @field = field
        @block = block
      end

      def apply(caller, json)
        json[@field] = caller.instance_eval(&@block)
      end
    end

    # @private
    class BlockDeserialization
      def initialize(field, block, klass)
        @field       = field
        @method_name = "_block_deserialization_#{object_id}"
        klass.send :define_method, @method_name, &block
      end

      def apply(caller, json)
        caller.send @method_name, json[@field.to_s]
      end
    end
  end
end
